Skip to content

1757 - Decimal precision support - #2377

Merged
xuri merged 3 commits into
qax-os:masterfrom
ValeryVerkhoturov:decimal-support
Aug 26, 2026
Merged

1757 - Decimal precision support#2377
xuri merged 3 commits into
qax-os:masterfrom
ValeryVerkhoturov:decimal-support

Conversation

@ValeryVerkhoturov

@ValeryVerkhoturov ValeryVerkhoturov commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

PR Details

Description

SetCellValue now recognizes arbitrary precision decimal types and stores them
as numbers rather than falling through to fmt.Sprint and becoming a string.

Detection is structural, so no dependency is added. Both of the widely used
decimal packages expose the same two methods on a value receiver, which is
enough to match on:

type decimalValue interface {
	Float64() (float64, bool)
	String() string
}

github.com/shopspring/decimal has Float64() (f float64, exact bool) and
github.com/govalues/decimal has Float64() (f float64, ok bool), so both
satisfy it as-is, as do pointers to them.

The cell is written from String() rather than from Float64(), so the digits
never pass through a float64 and nothing is rounded on the way out:

<c r="A1"><v>1234567890.12345678</v></c>

That is the same shape excelize already emits for a float64: no t
attribute, which is the implicit number type.

Covered paths:

  • SetCellValue, and therefore SetSheetRow / SetSheetCol
  • StreamWriter.SetRow, via setCellValFunc

Fallbacks are conservative, so nothing that works today changes:

  • A nil pointer is rejected before any method call, so it cannot panic and
    still renders through the existing fmt.Sprint path.
  • If String() does not yield something isNumeric accepts, the value falls
    through to the current string path. This matters because *math/big.Rat also
    satisfies the interface, but its String() returns "3/2", so it stays a
    string exactly as it does now.

One behavior change worth calling out explicitly: a type that implements that
method set and was previously stored as a string will now be stored as a
number. That is the point of the change, but it is a visible difference for
anyone who was relying on the old stringly behavior.

Related Issue

#1757

Motivation and Context

Issue #1757 asks for storing decimal precision values natively, for financial
data where a float64 is not an acceptable carrier.

Today the only two options both lose something. Passing
decimal.InexactFloat64() produces a number cell but silently rounds once an
amount needs more than about 15 significant digits. Passing the decimal object
itself hits the default branch of SetCellValue, which stringifies it, so the
cell becomes text: Excel will not sum it, SUM skips it, and the column is not
a number at all.

This change gives the third option, a number cell holding the exact digits, with
no dependency on either decimal package.

On precision, and where it does and does not survive

This is worth being precise about, because the two audiences differ.

In the Excel GUI, precision beyond ~15 significant digits is still lost.
Excel stores numbers as IEEE-754 doubles and displays at most 15 significant
digits under the General format, so 1234567890.12345678 shows as
1234567890.12346, and a workbook opened and re-saved in Excel comes back
rounded. No library-side change can avoid that; it is a property of the file
format's consumer.

Read back programmatically, nothing is lost. The exact digits are what land
in the XML, so a reader that asks for the raw value gets the original decimal
back:

raw, err := f.GetCellValue("Sheet1", "A1", excelize.Options{RawCellValue: true})
amount, err := decimal.NewFromString(raw) // == the value written

GetRows takes the same option. Note the qualifier: without RawCellValue the
General number format is applied on read and the value is rounded to what Excel
would display, so the raw option is required on the read side of a round trip.

That second case is the one that matters most in practice, and it is the
motivating workload here: pipelines that write a workbook, put it in S3, read it
back elsewhere, and load it into a database column typed DECIMAL. Nothing in
that path opens Excel, so the bytes are never re-rounded, and with this change
the amount that reaches the database is the amount that was written. Before it,
the same pipeline had to choose between a lossy float64 and a text column it
then had to reparse.

How Has This Been Tested

Environment: go1.25.11, darwin/arm64.

  • TestSetCellValueDecimal added in cell_test.go, covering the value form,
    the pointer form, a nil pointer, a *big.Rat non-regression to confirm a
    non-numeric String() still stores as a string, the StreamWriter path, and
    the inline-string overwrite fix.
  • Full suite green: go test . passes.
  • gofmt and go vet clean.

Verified against the real packages, not only against a mock implementing the
interface. In a separate module depending on shopspring/decimal v1.4.0 and
govalues/decimal v0.1.36 with a replace onto this branch, both produce
<c r="A1"><v>...</v></c> with no t attribute, identical to the float64
path, and round-trip tests write a workbook, save it, reopen it with OpenFile
and read it back:

source amount float64 shopspring govalues
19.99 ok ok ok
1234567890.12345678 1234567890.1234567 ok ok
9007199254740993.01 9007199254740994 ok ok

The same module pins the read-side behavior described above: raw reads return
the exact digits after a full save and reopen, including through the stream
writer, while a formatted read returns the rounded General-format value.

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

@ValeryVerkhoturov

Copy link
Copy Markdown
Contributor Author

Also you may check my tests of writing and reading Excel file with decimal lib dependencies https://github.com/ValeryVerkhoturov/excelize-decimal-test

@xuri xuri added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Aug 15, 2026

@xuri xuri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your pull request, I've left a comments.

Comment thread cell.go Outdated
@xuri xuri added size/M Denotes a PR that changes 30-99 lines, ignoring generated files. and removed size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Aug 26, 2026

@xuri xuri left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've reverted changes of decimal-support and align SetCellValue numeric precision with Excel based on your branch.

@ValeryVerkhoturov

ValeryVerkhoturov commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

It will work like the Excel GUI pasting now, but I'm still losing precision in the pipeline Decimal DB column → xlsx → Decimal DB column, since the values have to be converted to float along the way. Would it be a good idea to add non-standard behavior option that preserves the exact precision?

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.70%. Comparing base (c65972d) to head (da8b3d6).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #2377   +/-   ##
=======================================
  Coverage   99.70%   99.70%           
=======================================
  Files          32       32           
  Lines       32382    32416   +34     
=======================================
+ Hits        32287    32321   +34     
  Misses         93       93           
  Partials        2        2           
Flag Coverage Δ
unittests 99.70% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@xuri

xuri commented Aug 26, 2026

Copy link
Copy Markdown
Member

I think the default expectation when using this library is that it behaves consistently with Excel, so storing values with a precision beyond what Excel supports is unnecessary. However, you can store high-precision values as strings using the SetCellStr function, and then parse those string values back into a high-precision numeric type when reading them.

@xuri
xuri merged commit 2bf2c37 into qax-os:master Aug 26, 2026
20 checks passed
@xuri xuri added this to v2.11.1 Aug 26, 2026
@xuri xuri moved this to Improve the Compatibility in v2.11.1 Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M Denotes a PR that changes 30-99 lines, ignoring generated files.

Projects

Status: Improve the Compatibility

Development

Successfully merging this pull request may close these issues.

2 participants